Skip to content

fix(precompiles): fail closed on native coin burn supply underflow - #333

Open
crazywriter1 wants to merge 1 commit into
circlefin:mainfrom
crazywriter1:fix/native-coin-burn-supply-underflow
Open

fix(precompiles): fail closed on native coin burn supply underflow#333
crazywriter1 wants to merge 1 commit into
circlefin:mainfrom
crazywriter1:fix/native-coin-burn-supply-underflow

Conversation

@crazywriter1

@crazywriter1 crazywriter1 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Mirror mint: replace saturating_sub on total supply with checked_sub + ERR_OVERFLOW so burn fails closed if supply ever underflows.
  • Under the current Arc invariant (total_supply == sum(balances) at genesis, preserved by mint/burn/transfer and fee accounting), that underflow is unreachable after a successful balance_decr — so this is fail-closed hardening, not a live consensus bug, and is intentionally not hardfork-gated.
  • The only known balance/supply skew in-tree (self-destructed beneficiary fee credit) drives supply above balances, which makes underflow less likely, not more.

Test plan

  • cargo test -p arc-precompiles burn_reverts_when_total_supply_underflows
  • cargo test -p arc-precompiles native_coin_authority_precompile_outputs

@kutluhaneth46

Copy link
Copy Markdown

Looks solid — mirroring mint's checked_add with checked_sub + fail-closed is the right invariant when supply can lag balances.

One small note for reviewers: reuse of ERR_OVERFLOW ("Arithmetic overflow") for the underflow path matches mint's error surface, so it's intentional rather than a misnamed constant. The test comment about not pinning gas_used (because balance_decr already ran) is a good catch.

No code change requested from me.

@osr21 osr21 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disclosure: I'm not affiliated with Circle — an external community contributor, not a maintainer. I have no write access to this repository, so any review state I set (approval or change request) carries no merge authority and is advisory only. Please treat this as one contributor's technical assessment, and defer to Circle maintainers for the binding review.


Reviewed the diff and traced the reachability question. The change is correct and I'd merge it — but as defence-in-depth, not as a bug fix. The failure mode in the description isn't reachable on any current Arc network, and I think that's worth establishing explicitly, because it decides whether this needs a hardfork gate.

The supply invariant is seeded exactly at genesis

TOTAL_SUPPLY_STORAGE_KEY is slot 2 on NATIVE_COIN_AUTHORITY_ADDRESS. Both shipped genesis files set it to precisely the sum of allocated balances:

network slot 2 sum of alloc balances match
mainnet 0x21e19e0c9bab2400000 (1e22) 1e22 across 1 funded account exact
testnet 0x54b40b1f852bda00000 (2.5e22) 2.5e22 across 25 accounts exact

So total_supply == sum(balances) holds at block 0 by construction, not by accident.

Every balance-mutating path preserves it

I grepped for all native-balance mutation sites across crates/evm and crates/precompiles:

  • mintsupply += amt then balance_incr(amt). Preserved.
  • burnbalance_decr(amt) then supply -= amt. Preserved.
  • transferbalance_decr + balance_incr, explicitly net-zero (native_coin_authority.rs:332). Supply untouched, correctly.
  • Fees are a pure transfer, not a burn. reward_beneficiary (handler.rs:85-118) credits basefee + priority to the beneficiary, with the comment "This overrides the default EIP-1559 behavior which burns the base fee." The caller is debited by the standard revm path, so the fee is a move, not a destruction. Preserved.
  • system_accounting.rs and native_coin_control.rszero balance mutations. Neither touches native balances at all.

The one path that does break the invariant breaks it the safe way

handler.rs:103-111 documents it: crediting a self-destructed beneficiary "silently burns the fee at commit." Zero8 rejects that condition, but pre-Zero8 it can happen — balances drop while supply stays put.

That drives total_supply above sum(balances). Underflow needs the opposite drift. So the only known invariant-breaking mechanism in the tree makes checked_sub strictly less likely to fail, never more.

Which makes the burn precondition airtight

balance_decr has already succeeded when the subtraction runs, so:

amount <= balance(from) <= sum(balances) <= total_supply

checked_sub cannot return None. The deleted comment's conclusion was right — though its stated reason ("due to the balance check") was incomplete. The balance check alone only gives amount <= balance(from); you need the global invariant for the second step. If you keep a comment here, that's the version worth writing down, since it's the part a future reader can't re-derive locally.

The part I'd want a maintainer to rule on: gating

This changes a state-transition outcome — a burn that previously succeeded with supply saturating to 0 now reverts and rolls back. That's consensus-observable if it can ever trigger, and the PR adds it ungated.

Ungated is the right call only because it's unreachable. That's a real constraint, not a formality — this codebase gates every observable behavior change in these precompiles:

  • native_coin_control.rs:132,146,250,264 — Zero8
  • helpers.rs:574,608 — Zero8
  • system_accounting.rs:660,668,670 — Zero5/Zero6
  • handler.rs:105 — the selfdestructed-beneficiary guard above, Zero8

So the PR can't have it both ways: if the invariant genuinely can lag (the premise in the description), this is a consensus change and needs a ArcHardfork gate like its neighbours. If it can't lag, the change is inert and safe ungated — but then "burn could silently zero supply" overstates it, and the PR risks being triaged as a security fix when it's hardening.

I'd suggest rewording the description to something like "unreachable under the total_supply == sum(balances) invariant; added as fail-closed hardening, therefore not hardfork-gated" — so a reviewer doesn't have to re-derive the reachability argument to approve it.

On the ERR_OVERFLOW reuse

@kutluhaneth46's point that this matches mint's error surface is right, but I checked the other uses and there's no existing precedent for it on an underflow path — both are genuine overflows:

  • helpers.rs:444TransferError::OverflowPayment
  • helpers.rs:486checked_add overflow in balance_incr

helpers.rs already exports ERR_INSUFFICIENT_FUNDS, which is closer to what a supply shortfall actually is. Not worth blocking over — consistency with mint is a defensible tiebreak — but it's a new semantic for the constant rather than an established one.

Minor while you're here: ERR_OVERFLOW is defined twice with identical text — helpers.rs:51 (pub) and native_coin_authority.rs:57 (local, shadowing the shared one). The local could just be dropped in favour of the import.

Test

Good instinct putting this in crates/ as a Rust unit test — that actually executes in Public CI under cargo nextest. Worth knowing that the tests/**/*.test.ts hardhat suites are invoked only by make test-unit-hardhat and no workflow calls them, so a regression test placed there would never have run.

The note about not pinning gas_used is correct. One addition worth making: the test asserts the revert reason but not the rollback. Since "fail closed" is the actual claim, asserting that total_supply is still 0 and ADDRESS_A's balance is unchanged afterwards would pin the behaviour that matters, rather than just the error string.


Not requesting changes — the code is right and the test is real. The reachability framing and the gating question are what I'd want resolved in the description before merge. Usual caveat: no cargo or rustc available here, so this is source review plus arithmetic on the committed genesis JSON, not a test run.

@crazywriter1

Copy link
Copy Markdown
Contributor Author

@kutluhaneth46 @osr21 thanks both.

On ERR_OVERFLOW: intentional — same surface as mint, not a misnamed constant.

On reachability/gating: agreed. Description updated to frame this as unreachable fail-closed hardening under total_supply == sum(balances) (so ungated on purpose), including that the known self-destructed-beneficiary skew pushes supply above balances, not below.

@osr21

osr21 commented Sep 5, 2026

Copy link
Copy Markdown

Thanks — description reads accurately now. It matches what I traced independently (genesis slot 2 equals the alloc sum exactly on both networks, mint/burn/transfer and reward_beneficiary all preserve the invariant, and the selfdestructed-beneficiary skew is the one exception and it pushes supply up). And ERR_OVERFLOW is your call to make — consistency with mint is a defensible tiebreak.

One thing I got wrong in my review, and it changes the test suggestion I made. I suggested asserting that ADDRESS_A's balance is unchanged after the revert. That assertion would fail, and it's worth being precise about why, because it says something about what "fail closed" means here.

The unit harness calls PrecompilesMap::run (alloy-evm 0.34.0, precompiles.rs:536), which hands the precompile an EvmInternals over the live journal and takes no checkpoint. The rollback lives one layer up, in revm-handler 18.1.0 frame.rs:

// frame.rs:178
let checkpoint = ctx.journal_mut().checkpoint();
...
// frame.rs:203-213
if let Some(result) = precompiles.run(ctx, &inputs)... {
    if result.result.is_ok() {
        ctx.journal_mut().checkpoint_commit();
    } else {
        logs = ctx.journal_mut().logs()[checkpoint.log_i..].to_vec();
        ctx.journal_mut().checkpoint_revert(checkpoint);
    }

So balance_decr's debit is still in the journal when provider.run returns — atomicity is a frame guarantee, not a precompile one, and a test at this level structurally cannot pin it. If you want the debit-rollback covered, it needs an EVM-level test that goes through a call frame; otherwise it's already covered by revm's own semantics and arguably not yours to test.

What is assertable in this harness, and still worth adding, is that the failing path wrote nothing of its own:

// total supply untouched: the `write` sits after the checked_sub
let supply = ctx.journal_mut()
    .sload(NATIVE_COIN_AUTHORITY_ADDRESS, TOTAL_SUPPLY_STORAGE_KEY.into())
    .expect("read total supply");
assert_eq!(supply.data, U256::ZERO);
// and no EIP-7708 Transfer was emitted for a burn that didn't happen
assert!(ctx.journal_mut().logs().is_empty());

Why the log half of that isn't just decoration

The new return lands between the read and the write, and critically before emit_event. That ordering is what keeps this safe, and it's worth a comment so a later edit doesn't quietly move the check down. On the revert path revm doesn't drop what the precompile logged — frame.rs:212 snapshots logs()[checkpoint.log_i..] into precompile_call_logs before reverting the checkpoint, and in-tree evm.rs:1409-1411 chains those into what's handed to the inspector whenever was_precompile_called. Receipts follow the journal and so are unaffected, but a revert placed after emit_event would surface a Transfer(from, 0x0, amount) to tracers for a burn that reverted. Yours doesn't. Just don't let it drift.

Sizing the "hardening" framing

Since the PR now leans on being defence-in-depth rather than a fix, the useful question is whether it closes the class or just one instance. I grepped every saturating_*/wrapping_*/overflowing_* in crates/precompiles/src and crates/evm/src at 90c3210:

site what it is
call_from.rs:59,69, helpers.rs:643-644 gas cost math
helpers.rs:68 Vec::with_capacity
helpers.rs:722,744 storage-read counters
executor.rs:308 parent block number
helpers.rs:998, handler.rs:809 test code

No balance or supply arithmetic saturates anywhere else. This was the last one in that class, which is a stronger claim than the description currently makes and worth a line in it.

Still outstanding from my review

ERR_OVERFLOW remains defined twice with identical text — helpers.rs:51 (pub) and native_coin_authority.rs:57 (local, shadowing it). The file already imports a batch of siblings from helpers, so the local const can just be dropped from the list. Cosmetic, no behavior change, but it means a future edit to the shared constant silently won't reach this file.


Verification caveat: still no cargo or rustc available to me, so this is source review plus reading the pinned dependency sources (alloy-evm 0.34.0 and revm-handler 18.1.0 as resolved in Cargo.lock) — CI is authoritative.

Disclosure: I'm an external community contributor, unaffiliated with Circle, with no write access to this repository. My reviews and approvals are advisory only and carry no merge authority.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants